<?php

Raise::load('core.RaiseObject');
Raise::load('core.RaiseGzip');
Raise::load('core.RaiseConvert');
Raise::load('core.RaiseFileSystem');
Raise::load('core.RaiseFileStream');
Raise::load('core.RaiseException');
Raise::load('core.RaisePath');
Raise::load('core.RaiseFile');
Raise::load('core.RaiseStream');

/**
 * RaiseArchive class
 * Archives multiple files into one.
 *
 * The archive is stacked upon GZIP -> Base64 -> RaiseArchive format
 *
 * @author Sam-Mauris Yong <hellclanner at live dot com>
 * @license http://www.opensource.org/licenses/bsd-license New BSD License
 * @package Raise.Core.IO
 * @since 1.2
 */
class RaiseArchive extends RaiseObject {

    /**
     * The pathname of the actual archive
     * @var string
     */
    private $file;

    /**
     * The file handle
     * @var resource
     */
    private $fh;

    /**
     * All entries in the archive.
     * Each element is a RaiseArchiveEntry object
     * @var array
     */
    private $entries = array();

    /**
     * Sets whether the file is opened or not.
     * @var bool
     */
    private $opened = false;

    /**
     * The stream used to access the file.
     * @var RaiseFileStream
     */
    private $stream;

    /**
     * Sets whether to use compression on the archives
     * @var bool
     */
    public $useCompression = true;

    /**
     * Constructs a new RaiseArchive
     * @param string $f Pathname to the archive file. If it does not exist, RaiseArchive will create it.
     * @param boolean $c (optional) Whether to use compression or not. Defaults to TRUE.
     */
    function __construct($f, $c = true){
        $this->file = $f;
        // force disable compression if RaiseGzip is not loaded / not enabled
        if(!class_exists('RaiseGzip')){
            $c = false;
        }
        $this->useCompression = $c;
    }

    /**
     * Open the archive file. You must open the file before being able to do any
     * operations with the file.
     */
    function open(){
        if($this->stream){
            return;
        }
        $f = $this->file;
        if(RaiseFileSystem::fileExist($f)){
            /**
             * If Zlib is enabled and Gzip is loaded, check if the file is compressed with gzip
             * For compatibility reasons, we must check and decompress the file first if it is compressed.
             * otherwise process the file normal
             */
            if(class_exists('RaiseGzip') && RaiseGzip::getFileCompressionType($f) == 'gzip'){
                RaiseGzip::uncompressFile($f, $this->file . '.uctmp');
                $f = $this->file . '.uctmp';
                if(!$this->useCompression){
                    rename($f, $this->file);
                    $f = $this->file;
                }
            }
        }
        $this->entries = array();
//        if(RaiseFileSystem::fileExist($f)){
//            $this->fh = fopen($f, 'r+');
//            $this->parse();
//        }else{
//            $this->fh = fopen($f, 'w+');
//        }
        $this->stream = new RaiseFileStream($f);
    }

    /**
     * Adds a file entry to the archive
     * @param string $file The actual pathname to the file to add
     * @param string $path (optional) The virtual path in the archive to reside the file
     * @return RaiseArhiveEntry|bool The actual RaiseArchiveEntry is returned if operation
     *         is successful, FALSE otherwise.
     */
    function add($file, $path = ''){
        if(!$this->stream){
            throw new RaiseException('Entry cannot be added because the RaiseArchive is closed.', 0);
            return false;
        }
        if(substr($path, -1) == '/' || substr($path, -1) == '\\'){
            $path = substr($path, 0, strlen($path) - 1);
        }
        if(array_key_exists($path . $file, $this->entries)){
            return false;
        }
        $f = new RaiseFile($file);
        $entry = new RaiseArchiveEntry($file, $path);
        $entry->size = $f->size();
        $entry->dateTimeModified = $f->lastModified();
        $entry->data = $f->getContent();
        $this->entries[$entry->path . $entry->filename] = $entry;
        return $entry;
    }

    /**
     * Get the list of all entries
     * @return array An array of pathnames of the entries in the archive
     */
    function getEntryList(){
        if(!$this->stream){
            throw new RaiseException('Entry list cannot be fetched because the RaiseArchive is closed.', 0);
            return false;
        }
        $a = array_keys($this->entries);
        return $a;
    }

    /**
     * Get a specific entry using the pathname
     * @param string $pathname The entry to retrieve
     * @return RaiseArchiveEntry|bool The actual RaiseArchiveEntry is returned if operation
     *         is successful, FALSE otherwise.
     */
    function getEntry($pathname){
        if(!$this->stream){
            throw new RaiseException('Entry cannot be fetched because the RaiseArchive is closed.', 0);
            return false;
        }
        if(array_key_exists($pathname, $this->entries)){
            return $this->entries[$pathname];
        }
        return null;
    }

    /**
     * Delete a specific archive entry using the pathname
     * @param string $pathname The entry to remove
     * @return bool Returns TRUE if the operation is successful, FALSE otherwise.
     * @see RaiseArchive::commit()
     */
    function deleteEntry($pathname){
        if(!$this->stream){
            throw new RaiseException('Entry cannot be deleted because the RaiseArchive is closed.', 0);
            return false;
        }
        if(array_key_exists($pathname, $this->entries)){
            unset($this->entries[$pathname]);
            return true;
        }
        return false;
    }

    /**
     * Extract a file, some files or all the files from the archive to a folder
     * @param string $output (optional) The directory to extract all files to. If directory does not exist, RaiseArchive will create it.
     * @param string|array|bool $pathname (optional) Specify an array to extract specific files, a string to only extract a specific file, or FALSE to extract everything.
     * @return integer Returns the number of files extracted.
     */
    function extract($output = '', $pathname = false){
        if(!$this->stream){
            throw new RaiseException('Entries cannot be extracted because the RaiseArchive is closed.', 0);
            return false;
        }
        $extracts = array();
        if($pathname){
            if(is_string($pathname) && array_key_exists($pathname, $this->entries)){
                $extracts[$this->entries[$pathname]->path . $this->entries[$pathname]->filename] = $this->entries[$pathname];
            }elseif(is_array($pathname)){
                foreach($pathname as $p){
                    /** @var $p string */
                    if(array_key_exists($p, $this->entries)){
                        $extracts[$this->entries[$p]->path . $this->entries[$p]->filename] = $this->entries[$p];
                    }
                }
            }
        }else{
            $extracts = $this->entries;
        }
        if(substr($output, -1) == '/' || substr($output, -1) == '\\'){
            $output = substr($output, 0, strlen($output) - 1);
        }
        foreach($extracts as $k => $e){
            /** @var $e RaiseArchiveEntry */ /** @var $k string */
            if(!$e->encodedData && !$e->data){
                $e = $this->fetchEntryEncodedData($e);
                $e->decode();
            }elseif($e->encodedData && !$e->data){
                $e->decode();
            }
            RaiseFileSystem::createPath($output . RaisePath::directorySeparator());
            $f = new RaiseFile($output . RaisePath::directorySeparator() . $e->path . RaisePath::directorySeparator() . $e->filename);
            $f->setContent($e->data);
            $f->setLastModified($e->dateTimeModified);
            $f->close();
        }
        return count($extracts);
    }

    /**
     * Parse the archive file for entries
     * @return bool Returns FALSE if the archive is not opened, otherwise TRUE indicating success.
     */
    private function parse(){
        if(!$this->stream){
            throw new RaiseException('RaiseArchive cannot be parsed because the RaiseArchive is closed.', 0);
            return false;
        }
        //fseek($this->fh, 0);
        $this->stream->seek(0);
        // test.jpg|test/folder|20100801T1200002131|3123>data...
        // Filename|path|dateTimeModifiedsize|encodedLength>data
        /** @var $buffer string */
        $buffer = '';
        //$c = fgetc($this->fh);
        $c = true;
        while($c !== false){
            $c = $this->stream->read(1);
            if($c == '>'){
                list($filename, $path, $modifiedsize, $encodedLength) = explode('|', $buffer);
                $entry = new RaiseArchiveEntry($filename, $path);
                $entry->dateTimeModified = RaiseDateTime::fromString(substr($modifiedsize, 0, 15));
                $entry->size = RaiseConvert::arbitraryBase(substr($modifiedsize, 15), 36, 10);
                $entry->encodedLength = RaiseConvert::arbitraryBase($encodedLength, 36, 10);
                $this->entries[$path . $filename] = $entry;
                $buffer = '';
                // fseek($this->fh, $entry->encodedLength, SEEK_CUR);
                $this->stream->seek($this->stream->position() + $entry->encodedLength);
            }else{
                $buffer .= $c;
            }
        }
        return true;
    }

    /**
     * Fetch the Base64 Encoded data from the archive file for a specific entry
     * @param RaiseArchiveEntry $entry The particular entry to retrieve its encoded data
     * @return bool|RaiseArchiveEntry Returns FALSE if the archive is not opened, otherwise the RaiseArchiveEntry with its encoded data fetched.
     */
    private function fetchEntryEncodedData($entry){
        if(!$this->opened){
            throw new RaiseException('Entry encoded data cannot be fetched because the RaiseArchive is closed.', 0);
            return false;
        }
        $buffer = '';
        //fseek($this->fh, 0);
        $this->stream->seek(0);
        $readdata = false;
        while(true){
            //$c = fgetc($this->fh);
            $c = $this->stream->read(1);
            if($c === false){
                break;
            }elseif($c == '>'){
                list(, , , $encodedLength) = explode('|', $buffer);
                $encodedLength = RaiseConvert::arbitraryBase($encodedLength, 36, 10);
                if($buffer == $entry->header()){
                    if($encodedLength == 0){
                        $entry->encodedData = '';
                    }else{
                        //$encodedData = fread($this->fh, $encodedLength);
                        $encodedData = $this->stream->read($encodedLength);
                        $entry->encodedData = $encodedData;
                        break;
                    }
                }else{
                    $buffer = '';
                    //fseek($this->fh, $encodedLength, SEEK_CUR);
                    $this->stream->seek($this->stream->position() + $encodedLength);
                }
            }else{
                $buffer .= $c;
            }
        }
        return $entry;
    }

    /**
     * Commit the changes made to the archive
     * @return bool Returns FALSE if the archive is not opened, otherwise TRUE indicating success.
     */
    function commit(){
        if(!$this->stream){
            throw new RaiseException('Commit operation failed because the RaiseArchive is closed.', 0);
            return false;
        }
        //$tmp = fopen($this->file . '.tmp', 'w');
        $tempStream = new RaiseStream($this->file . '.tmp');
        foreach($this->entries as $entry){
            /** @var $entry RaiseArchiveEntry */
            
            if(!$entry->encodedData && !$entry->data){
                $entry = $this->fetchEntryEncodedData($entry);
            }elseif(!$entry->encodedData && $entry->data){
                $entry->encode();
            }
            
            $tempStream->write($entry->header());
            $tempStream->write('>' . $entry->encodedData);

            //fwrite($tmp, $entry->header());
            //fwrite($tmp, '>' . $entry->encodedData);
        }
        //fclose($tmp);
        $tempStream->close();
        unset($tempStream);
        $this->close();
        if($this->useCompression){
            RaiseGzip::compressFile($this->file . '.tmp', $this->file);
            unlink($this->file . '.tmp');
        }else{
            rename($this->file . '.tmp', $this->file);
        }
        $this->open();
        return true;
    }

    /**
     * Cleans up the mess =)
     * No seriously don't ignore me
     * @ignore
     */
    function __destruct(){
        $this->close();
    }

    /**
     * Close the archive and free up resources
     */
    function close(){
        unset($this->entries);
        $this->entries = array();
        if($this->stream){
            //fclose($this->fh);
            $this->stream->close();
            unset($this->stream);
            $this->stream = null;
            if($this->useCompression){
                chdir(RaisePath::scriptPath());
                @unlink($this->file . '.uctmp');
            }
        }
    }

}

/**
 * RaiseArchiveEntry class
 * represents a file entry in the archive
 */
class RaiseArchiveEntry extends RaiseObject {

    /**
     * Filename of the entry
     * @var string
     */
    public $filename;

    /**
     * The virtual directory path of the file in the archive
     * @var string
     */
    public $path;

    /**
     * The last modified attribute of the entry
     * @var RaiseDateTime
     */
    public $dateTimeModified;

    /**
     * Actual file size of the file
     * @var integer
     */
    public $size;

    /**
     * Actual file content
     * @var string
     */
    public $data;

    /**
     * The length of the encoded file content
     * @var integer
     */
    public $encodedLength;

    /**
     * The encoded file content
     * @var string
     */
    public $encodedData;

    /**
     * Create a new RaiseArchiveEntry
     * @param string $f The file name of the entry
     * @param string $p (optional) The virtual directory path which the entry resides in the archive
     */
    public function __construct($f, $p = ''){
        if($path == '/'){
            $path = '';
        }
        $this->filename = RaisePath::basename($f);
        $this->path = $p;
    }

    /**
     * Decode the encoded file content
     * to get the actual file content
     */
    public function decode(){
        $this->data = RaiseConvert::fromBase64($this->encodedData);
    }

    /**
     * Encode the actual file content
     */
    public function encode(){
        $this->encodedData = RaiseConvert::toBase64($this->data);
        $this->encodedLength = strlen($this->encodedData);
    }

    /**
     * Generates the RaiseArchiveEntry header
     * @return string
     */
    public function header(){
        return $this->filename . '|' . $this->path . '|' . $this->dateTimeModified->toFormat('Ymd\THis') . RaiseConvert::arbitraryBase($this->size, 10, 36) . '|' . RaiseConvert::arbitraryBase($this->encodedLength, 10, 36);
    }
}